On this page

Skip to content

How to Customize Default Error Messages for ASP.NET Core Model Validation

Introduction

ASP.NET Core Model Validation currently provides messages in English only. For example, the RequiredAttribute provides the error message "The {Column Name} field is required." on the backend. Manually setting the message for every "Required" field can be tedious. Over the years, many developers have requested that the ++Microsoft++ development team provide multi-language packs, but they have consistently maintained that it is not a necessary feature. However, ++Microsoft++ does provide a way to customize these messages. For detailed instructions, you can refer to this article.

Implementation

Default validation in Model Validation is divided into two parts: ModelBinding validation, which is primarily related to data formats, and ValidationMetadata validation, which is related to data content. These two features must be implemented separately.

Create Resource Files (.resx)

The access modifier for the resource file can be set to "Internal" or "Public," depending on your actual requirements.

The property settings are as follows:

NameValue
Build ActionEmbedded Resource
Copy to Output DirectoryDo not copy

The ModelBindingMessage content is as follows:

NameValue
AttemptedValueIsInvalidThe value {0} is invalid for {1}.
MissingBindRequiredValueA value for the '{0}' property was not provided.
MissingKeyOrValueA value is required.
MissingRequestBodyRequiredValueA non-empty request body is required.
NonPropertyAttemptedValueIsInvalidThe value {0} is invalid.
NonPropertyUnknownValueIsInvalidThe supplied value is invalid.
NonPropertyValueMustBeANumberThe field must be a number.
UnknownValueIsInvalidThe supplied value is invalid for {0}.
ValueIsInvalidThe value {0} is invalid.
ValueMustBeANumberThe value {0} must be a number.
ValueMustNotBeNullThe value {0} must not be null.

The ValidationMetadataMessage content is as follows:

NameValue
CompareAttribute_MustMatchThe fields {0} and {1} do not match.
CreditCardAttribute_InvalidThe {0} field is not a valid credit card number.
CustomValidationAttribute_ValidationErrorThe data in the {0} field is invalid.
EmailAddressAttribute_InvalidThe {0} field is not a valid email format.
FileExtensionsAttribute_InvalidThe {0} field only accepts files with the following extensions: {1}.
MaxLengthAttribute_ValidationErrorThe {0} field can have a maximum length of {1}.
MinLengthAttribute_ValidationErrorThe {0} field must have a minimum length of {1}.
PhoneAttribute_InvalidThe {0} field is not a valid phone number format.
RangeAttribute_ValidationErrorThe {0} field must be between {1} and {2}.
RegularExpressionAttribute_ValidationErrorThe {0} field does not match the regular expression '{1}'.
RequiredAttribute_ValidationErrorThe {0} field is required.
StringLengthAttribute_ValidationErrorThe {0} field can have a maximum length of {1}.
StringLengthAttribute_ValidationErrorIncludingMinimumThe {0} field length must be between {2} and {1}.
UrlAttribute_InvalidThe {0} field is not a valid HTTP, HTTPS, or FTP URL.

Create a Custom ValidationMetadataProvider

The purpose is to replace the error messages of ValidationAttribute.

csharp
public class LocalizationValidationMetadataProvider : IValidationMetadataProvider {
    private readonly ResourceManager resourceManager;
    private readonly Type resourceType;

    public LocalizationValidationMetadataProvider(Type type) {
        resourceType = type;
        resourceManager = new ResourceManager(type);
    }

    public void CreateValidationMetadata(ValidationMetadataProviderContext context) {
        foreach (var attribute in context.ValidationMetadata.ValidatorMetadata.OfType<ValidationAttribute>()) {
            if (attribute.ErrorMessageResourceName is null) {
                bool hasErrorMessage = attribute.ErrorMessage != null;

                if (hasErrorMessage) {
                    string? defaultErrorMessage = typeof(ValidationAttribute)
                        .GetField("_defaultErrorMessage", BindingFlags.NonPublic | BindingFlags.Instance)
                        ?.GetValue(attribute) as string;

                    // Some ValidationAttribute ErrorMessages are not null by default
                    hasErrorMessage = attribute.ErrorMessage != defaultErrorMessage;
                }

                if (hasErrorMessage) {
                    continue;
                }

                string? name = GetMessageName(attribute);
                if (name != null && resourceManager.GetString(name) != null) {
                    attribute.ErrorMessageResourceType = resourceType;
                    attribute.ErrorMessageResourceName = name;
                    attribute.ErrorMessage = null;
                }
            }
        }
    }

    private string? GetMessageName(ValidationAttribute attr) {
        switch (attr) {
            case CompareAttribute _:
                return "CompareAttribute_MustMatch";
            case StringLengthAttribute vAttr:
                if (vAttr.MinimumLength > 0) {
                    return "StringLengthAttribute_ValidationErrorIncludingMinimum";
                }
                return "StringLengthAttribute_ValidationError";
            case DataTypeAttribute _:
                return $"{attr.GetType().Name}_Invalid";
            case ValidationAttribute _:
                return $"{attr.GetType().Name}_ValidationError";
        }

        return null;
    }
}

Program.cs

csharp
builder.Services.AddRazorPages()
    .AddMvcOptions(options => {
        // Set ModelBinding error messages from resource files
        var provider = options.ModelBindingMessageProvider;
        provider.SetAttemptedValueIsInvalidAccessor((x, y) => string.Format(ModelBindingMessage.AttemptedValueIsInvalid, x, y));
        provider.SetMissingBindRequiredValueAccessor(x => string.Format(ModelBindingMessage.MissingBindRequiredValue, x));
        provider.SetMissingKeyOrValueAccessor(() => ModelBindingMessage.MissingKeyOrValue);
        provider.SetMissingRequestBodyRequiredValueAccessor(() => ModelBindingMessage.MissingRequestBodyRequiredValue);
        provider.SetNonPropertyAttemptedValueIsInvalidAccessor(x => string.Format(ModelBindingMessage.NonPropertyAttemptedValueIsInvalid, x));
        provider.SetNonPropertyUnknownValueIsInvalidAccessor(() => ModelBindingMessage.NonPropertyUnknownValueIsInvalid);
        provider.SetNonPropertyValueMustBeANumberAccessor(() => ModelBindingMessage.NonPropertyValueMustBeANumber);
        provider.SetUnknownValueIsInvalidAccessor(x => string.Format(ModelBindingMessage.UnknownValueIsInvalid, x));
        provider.SetValueIsInvalidAccessor(x => string.Format(ModelBindingMessage.ValueIsInvalid, x));
        provider.SetValueMustBeANumberAccessor(x => string.Format(ModelBindingMessage.NonPropertyValueMustBeANumber, x));
        provider.SetValueMustNotBeNullAccessor(x => string.Format(ModelBindingMessage.ValueMustNotBeNull, x));

        // Set ValidationMetadata error messages from resource files
        options.ModelMetadataDetailsProviders.Add(new LocalizationValidationMetadataProvider(typeof(ValidationMetadataMessage)));
    });

Multi-language Support

Actually, you don't need resource files to customize Model Validation error messages. The advantage of using resource files is that if you want to support multiple languages, you can expand the handling to include resource files for other languages. Since explaining multi-language support would require a long article, and I haven't implemented it myself, I will only extend the content related to multi-language support from the main topic here.

Create Multi-language Resource Files (.resx)

  1. Create "ModelBindingMessage.{culture}.resx" and "ValidationMetadataMessage.{culture}.resx".
  2. Set the access modifier of the resource file to "==No code generation==".
  3. Set the properties as follows (same as the default resource file):
NameValue
Build ActionEmbedded Resource
Copy to Output DirectoryDo not copy

The code generated from the "Default Resource File" will read the "Default Resource File" and the "Culture Resource File" based on the culture, so setting the culture resource file to "==No code generation==" is sufficient.

Configure Culture Settings

Program.cs

csharp
WebApplication app = builder.Build();

// List the cultures for which you have set up resource files
string[] supportedCultures = new string[] { "zh-TW", "en-US" }
RequestLocalizationOptions localizationOptions = new RequestLocalizationOptions()
    .SetDefaultCulture(supportedCultures[0])
    .AddSupportedCultures(supportedCultures)
    .AddSupportedUICultures(supportedCultures); // This is what actually takes effect

// Set localization settings
// If not set, the culture will be selected based on Thread.CurrentThread.CurrentUICulture
app.UseRequestLocalization(localizationOptions);

INFO

Common Misconceptions about Culture:

  1. RequestLocalizationOptions.DefaultRequestCulture Not Working RequestLocalizationOptions has a member called RequestCultureProviders, which defaults to the following three providers:

    1. QueryStringRequestCultureProvider.
    2. CookieRequestCultureProvider.
    3. AcceptLanguageHeaderRequestCultureProvider.

    DefaultRequestCulture can be understood as the provider with the lowest priority. Providers attempt to find the UICulture in order. Once found, they stop searching and use the found UICulture to determine if it is in the SupportedUICultures list. If it is, it then checks if the "Culture Resource File" exists. If it exists, the "Culture Resource File" is used. If the UICulture is not in the SupportedUICultures list or the "Culture Resource File" does not exist, the "Default Resource File" is used.

  2. Setting the wrong Culture property. In C#, any property related to Culture comes in two types:

    1. Culture: Used to determine the date, numeric, currency formats, comparison, and sorting of the culture.
    2. UICulture: Used to determine which language resource file to load.

    Some people often don't notice the difference between the two when using them. However, most people copying examples from the internet set both together, or the API itself is designed to set both when using an API with only one parameter. For example, SetDefaultCulture() requires two parameters to set them separately, so it's hard to make a mistake. Regarding QueryStringRequestCultureProvider, some online introductions mention that the URL parameter is culture={culture}, but it should correctly be ui-culture={culture}.

Change Log

  • 2022-10-05 Initial document created.
  • 2024-04-04 Fixed messages in ModelBindingMessage.